import * as path from 'path';
import * as ts from 'typescript';
import * as Lint from 'tslint';
import * as minimatch from 'minimatch';

/**
 * Rule that enforces certain decorator properties to be defined and to match a pattern.
 * Properties can be forbidden by prefixing their name with a `!`. Supports whitelisting
 * files via the third argument, as well as validating all the arguments by passing in a regex. E.g.
 *
 * ```
 * "validate-decorators": [true, {
 *   "Component": {
 *     "argument": 0,
 *     "properties": {
 *       "encapsulation": "\\.None$",
 *       "!styles": ".*"
 *     }
 *   },
 *   "NgModule": {
 *      "argument": 0,
 *      "properties": "^(?!\\s*$).+"
 *    }
 * }, "src/material"]
 * ```
 */
export class Rule extends Lint.Rules.AbstractRule {
  apply(sourceFile: ts.SourceFile) {
    return this.applyWithWalker(new Walker(sourceFile, this.getOptions()));
  }
}

/**
 * Token used to indicate that all properties of an object
 * should be linted against a single pattern.
 */
const ALL_PROPS_TOKEN = '*';

/** Object that can be used to configured the rule. */
interface RuleConfig {
  [key: string]: {
    argument: number,
    required?: boolean,
    properties: {[key: string]: string}
  };
}

/** Represents a set of required and forbidden decorator properties. */
type DecoratorRuleSet = {
  argument: number,
  required: boolean,
  requiredProps: {[key: string]: RegExp},
  forbiddenProps: {[key: string]: RegExp},
};

/** Represents a map between decorator names and rule sets. */
type DecoratorRules = {
  [decorator: string]: DecoratorRuleSet
};

class Walker extends Lint.RuleWalker {
  // Whether the file should be checked at all.
  private _enabled: boolean;

  // Rules that will be used to validate the decorators.
  private _rules: DecoratorRules;

  constructor(sourceFile: ts.SourceFile, options: Lint.IOptions) {
    super(sourceFile, options);

    // Globs that are used to determine which files to lint.
    const fileGlobs = options.ruleArguments.slice(1) || [];

    // Relative path for the current TypeScript source file.
    const relativeFilePath = path.relative(process.cwd(), sourceFile.fileName);

    this._rules = this._generateRules(options.ruleArguments[0]);
    this._enabled = Object.keys(this._rules).length > 0 &&
                    fileGlobs.some(p => minimatch(relativeFilePath, p));
  }

  visitClassDeclaration(node: ts.ClassDeclaration) {
    if (this._enabled) {
      if (node.decorators) {
        node.decorators.forEach(decorator => this._validateDecorator(decorator));
      }

      node.members.forEach(member => {
        if (member.decorators) {
          member.decorators.forEach(decorator => this._validateDecorator(decorator));
        }
      });
    }

    super.visitClassDeclaration(node);
  }

  /**
   * Validates that a decorator matches all of the defined rules.
   * @param decorator Decorator to be checked.
   */
  private _validateDecorator(decorator: ts.Decorator) {
    const expression = decorator.expression;

    if (!expression || !ts.isCallExpression(expression)) {
      return;
    }

    // Get the rules that are relevant for the current decorator.
    const rules = this._rules[expression.expression.getText()];
    const args = expression.arguments;

    // Don't do anything if there are no rules.
    if (!rules) {
      return;
    }

    const allPropsRequirement = rules.requiredProps[ALL_PROPS_TOKEN];

    // If we have a rule that applies to all properties, we just run it through once and we exit.
    if (allPropsRequirement) {
      const argumentText = args[rules.argument] ? args[rules.argument].getText() : '';
      if (!allPropsRequirement.test(argumentText)) {
        this.addFailureAtNode(expression.parent, `Expected decorator argument ${rules.argument} ` +
                                                 `to match "${allPropsRequirement}"`);
      }
      return;
    }

    if (!args[rules.argument]) {
      if (rules.required) {
        this.addFailureAtNode(expression.parent,
                             `Missing required argument at index ${rules.argument}`);
      }
      return;
    }

    if (!ts.isObjectLiteralExpression(args[rules.argument])) {
      return;
    }

    // Extract the property names and values.
    const props: {name: string, value: string, node: ts.PropertyAssignment}[] = [];

    (args[rules.argument] as ts.ObjectLiteralExpression).properties.forEach(prop => {
      if (ts.isPropertyAssignment(prop) && prop.name && prop.initializer) {
        props.push({
          name: prop.name.getText(),
          value: prop.initializer.getText(),
          node: prop
        });
      }
    });

    // Find all of the required rule properties that are missing from the decorator.
    const missing = Object.keys(rules.requiredProps)
        .filter(key => !props.find(prop => prop.name === key));

    if (missing.length) {
      // Exit early if any of the properties are missing.
      this.addFailureAtNode(expression.expression,
          'Missing required properties: ' + missing.join(', '));
    } else {
      // If all the necessary properties are defined, ensure that
      // they match the pattern and aren't in the forbidden list.
      props
        .filter(prop => rules.requiredProps[prop.name] || rules.forbiddenProps[prop.name])
        .forEach(prop => {
          const {name, value, node} = prop;
          const requiredPattern = rules.requiredProps[name];
          const forbiddenPattern = rules.forbiddenProps[name];

          if (requiredPattern && !requiredPattern.test(value)) {
            this.addFailureAtNode(node, `Invalid value for property. ` +
                                        `Expected value to match "${requiredPattern}".`);
          } else if (forbiddenPattern && forbiddenPattern.test(value)) {
            this.addFailureAtNode(node, `Property value not allowed. ` +
                                        `Value should not match "${forbiddenPattern}".`);
          }
        });
    }
  }

  /**
   * Cleans out the blank rules that are passed through the tslint.json
   * and converts the string patterns into regular expressions.
   * @param config Config object passed in via the tslint.json.
   * @returns Sanitized rules.
   */
  private _generateRules(config: RuleConfig|null): DecoratorRules {
    const output: DecoratorRules = {};

    if (config) {
      Object.keys(config).forEach(decoratorName => {
        const decoratorConfig = config[decoratorName];
        const {argument, properties, required} = decoratorConfig;

        // * is a special token which means to run the pattern across the entire object.
        const allProperties = properties[ALL_PROPS_TOKEN];

        if (allProperties) {
          output[decoratorName] = {
            argument,
            required: !!required,
            requiredProps: {[ALL_PROPS_TOKEN]: new RegExp(allProperties)},
            forbiddenProps: {}
          };
        } else {
          output[decoratorName] = Object.keys(decoratorConfig.properties).reduce((rules, prop) => {
            const isForbidden = prop.startsWith('!');
            const cleanName = isForbidden ? prop.slice(1) : prop;
            const pattern = new RegExp(properties[prop]);

            if (isForbidden) {
              rules.forbiddenProps[cleanName] = pattern;
            } else {
              rules.requiredProps[cleanName] = pattern;
            }

            return rules;
          }, {
            argument,
            required: !!required,
            requiredProps: {} as {[key: string]: RegExp},
            forbiddenProps: {} as {[key: string]: RegExp}
          });
        }
      });
    }

    return output;
  }
}
